// © 2020 and later: Unicode, Inc. and others.
// License & terms of use: http://www.unicode.org/copyright.html
package com.ibm.icu.impl.units;

import com.ibm.icu.impl.number.DecimalQuantity;
import com.ibm.icu.impl.number.DecimalQuantity_DualStorageBCD;
import com.ibm.icu.number.Precision;
import com.ibm.icu.util.Measure;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/**
 * Converts from single or compound unit to single, compound or mixed units. For example, from
 * {@code meter} to {@code foot+inch}.
 *
 * <p>DESIGN: This class uses <code>UnitsConverter</code> in order to perform the single converter
 * (i.e. converters from a single unit to another single unit). Therefore, <code>
 * ComplexUnitsConverter</code> class contains multiple instances of the <code>UnitsConverter</code>
 * to perform the conversion.
 */
public class ComplexUnitsConverter {
    public static final BigDecimal EPSILON = BigDecimal.valueOf(Math.ulp(1.0));
    public static final BigDecimal EPSILON_MULTIPLIER = BigDecimal.valueOf(1).add(EPSILON);

    // TODO(ICU-21937): Make it private after submitting the public units conversion API.
    public ArrayList<UnitsConverter> unitsConverters_;

    /**
     * Individual units of mixed units, sorted big to small, with indices indicating the requested
     * output mixed unit order.
     */
    // TODO(ICU-21937): Make it private after submitting the public units conversion API.
    public List<MeasureUnitImpl.MeasureUnitImplWithIndex> units_;

    private MeasureUnitImpl inputUnit_;

    /**
     * Constructs <code>ComplexUnitsConverter</code> for an <code>inputUnit</code> that could be
     * Single, Compound or Mixed. In case of: 1- Single and Compound units, the conversion will not
     * perform anything, the input will be equal to the output. 2- Mixed Unit the conversion will
     * consider the input in the biggest unit. and will convert it to be spread throw the input
     * units. For example: if input unit is "inch-and-foot", and the input is 2.5. The converter
     * will consider the input value in "foot", because foot is the biggest unit. Then, it will
     * convert 2.5 feet to "inch-and-foot".
     *
     * @param targetUnit represents the input unit. could be any type. (single, compound or mixed).
     */
    public ComplexUnitsConverter(MeasureUnitImpl targetUnit, ConversionRates conversionRates) {
        this.units_ = targetUnit.extractIndividualUnitsWithIndices();
        assert (!this.units_.isEmpty());

        // Assign the biggest unit to inputUnit_.
        this.inputUnit_ = this.units_.get(0).unitImpl;
        MeasureUnitImpl.MeasureUnitImplComparator comparator =
                new MeasureUnitImpl.MeasureUnitImplComparator(conversionRates);
        for (MeasureUnitImpl.MeasureUnitImplWithIndex unitWithIndex : this.units_) {
            if (comparator.compare(unitWithIndex.unitImpl, this.inputUnit_) > 0) {
                this.inputUnit_ = unitWithIndex.unitImpl;
            }
        }

        this.init(conversionRates);
    }

    /**
     * Constructs <code>ComplexUnitsConverter</code> NOTE: - inputUnit and outputUnits must be under
     * the same category - e.g. meter to feet and inches --> all of them are length units.
     *
     * @param inputUnitIdentifier represents the source unit identifier. (should be single or
     *     compound unit).
     * @param outputUnitsIdentifier represents the output unit identifier. could be any type.
     *     (single, compound or mixed).
     */
    public ComplexUnitsConverter(String inputUnitIdentifier, String outputUnitsIdentifier) {
        this(
                MeasureUnitImpl.forIdentifier(inputUnitIdentifier),
                MeasureUnitImpl.forIdentifier(outputUnitsIdentifier),
                new ConversionRates());
    }

    /**
     * Constructs <code>ComplexUnitsConverter</code> NOTE: - inputUnit and outputUnits must be under
     * the same category - e.g. meter to feet and inches --> all of them are length units.
     *
     * @param inputUnit represents the source unit. (should be single or compound unit).
     * @param outputUnits represents the output unit. could be any type. (single, compound or
     *     mixed).
     * @param conversionRates a ConversionRates instance containing the unit conversion rates.
     */
    public ComplexUnitsConverter(
            MeasureUnitImpl inputUnit,
            MeasureUnitImpl outputUnits,
            ConversionRates conversionRates) {
        this.inputUnit_ = inputUnit;
        this.units_ = outputUnits.extractIndividualUnitsWithIndices();
        assert (!this.units_.isEmpty());

        this.init(conversionRates);
    }

    /**
     * Sorts units_, which must be populated before calling this, and populates unitsConverters_.
     */
    private void init(ConversionRates conversionRates) {
        // Sort the units in a descending order.
        Collections.sort(
                this.units_,
                Collections.reverseOrder(
                        new MeasureUnitImpl.MeasureUnitImplWithIndexComparator(conversionRates)));

        // If the `outputUnits` is `UMEASURE_UNIT_MIXED` such as `foot+inch`. Thus means there is
        // more than one unit
        //  and In this case we need more converters to convert from the `inputUnit` to the first
        // unit in the
        //  `outputUnits`. Then, a converter from the first unit in the `outputUnits` to the second
        // unit and so on.
        //      For Example:
        //          - inputUnit is `meter`
        //          - outputUnits is `foot+inch`
        //              - Therefore, we need to have two converters:
        //                      1. a converter from `meter` to `foot`
        //                      2. a converter from `foot` to `inch`
        //          - Therefore, if the input is `2 meter`:
        //              1. convert `meter` to `foot` --> 2 meter to 6.56168 feet
        //              2. convert the residual of 6.56168 feet (0.56168) to inches, which will be
        // (6.74016
        //              inches)
        //              3. then, the final result will be (6 feet and 6.74016 inches)
        unitsConverters_ = new ArrayList<>();
        for (int i = 0, n = units_.size(); i < n; i++) {
            if (i == 0) { // first element
                unitsConverters_.add(
                        new UnitsConverter(
                                this.inputUnit_, units_.get(i).unitImpl, conversionRates));
            } else {
                unitsConverters_.add(
                        new UnitsConverter(
                                units_.get(i - 1).unitImpl,
                                units_.get(i).unitImpl,
                                conversionRates));
            }
        }
    }

    /**
     * Returns true if the specified {@code quantity} of the {@code inputUnit}, expressed in terms
     * of the biggest unit in the MeasureUnit {@code outputUnit}, is greater than or equal to {@code
     * limit}.
     *
     * <p>For example, if the input unit is {@code meter} and the target unit is {@code foot+inch}.
     * Therefore, this function will convert the {@code quantity} from {@code meter} to {@code
     * foot}, then, it will compare the value in {@code foot} with the {@code limit}.
     */
    public boolean greaterThanOrEqual(BigDecimal quantity, BigDecimal limit) {
        assert !units_.isEmpty();

        // NOTE: First converter converts to the biggest quantity.
        return unitsConverters_
                        .get(0)
                        .convert(quantity)
                        .multiply(EPSILON_MULTIPLIER)
                        .compareTo(limit)
                >= 0;
    }

    public static class ComplexConverterResult {
        public final int indexOfQuantity;
        public final List<Measure> measures;

        ComplexConverterResult(int indexOfQuantity, List<Measure> measures) {
            this.indexOfQuantity = indexOfQuantity;
            this.measures = measures;
        }
    }

    /**
     * Returns outputMeasures which is an array with the corresponding values. - E.g. converting
     * meters to feet and inches. 1 meter --> 3 feet, 3.3701 inches NOTE: the smallest element is
     * the only element that could have fractional values. And all other elements are floored to the
     * nearest integer
     */
    public ComplexConverterResult convert(BigDecimal quantity, Precision rounder) {
        BigInteger sign = BigInteger.ONE;
        if (quantity.compareTo(BigDecimal.ZERO) < 0 && unitsConverters_.size() > 1) {
            quantity = quantity.abs();
            sign = sign.negate();
        }

        // For N converters:
        // - the first converter converts from the input unit to the largest
        //   unit,
        // - N-1 converters convert to bigger units for which we want integers,
        // - the Nth converter (index N-1) converts to the smallest unit, which
        //   isn't (necessarily) an integer.
        List<BigInteger> intValues = new ArrayList<>(unitsConverters_.size() - 1);
        for (int i = 0, n = unitsConverters_.size(); i < n; ++i) {
            quantity = (unitsConverters_.get(i)).convert(quantity);

            if (i < n - 1) {
                // The double type has 15 decimal digits of precision. For choosing
                // whether to use the current unit or the next smaller unit, we
                // therefore nudge up the number with which the thresholding
                // decision is made. However after the thresholding, we use the
                // original values to ensure unbiased accuracy (to the extent of
                // double's capabilities).
                BigInteger flooredQuantity =
                        quantity.multiply(EPSILON_MULTIPLIER)
                                .setScale(0, RoundingMode.FLOOR)
                                .toBigInteger();
                intValues.add(flooredQuantity);

                // Keep the residual of the quantity.
                // For example: `3.6 feet`, keep only `0.6 feet`
                BigDecimal remainder =
                        quantity.subtract(BigDecimal.valueOf(flooredQuantity.longValue()));
                if (remainder.compareTo(BigDecimal.ZERO) == -1) {
                    quantity = BigDecimal.ZERO;
                } else {
                    quantity = remainder;
                }
            }
        }

        quantity = applyRounder(intValues, quantity, rounder);

        // Initialize empty measures.
        List<Measure> measures = new ArrayList<>(unitsConverters_.size());
        for (int i = 0; i < unitsConverters_.size(); i++) {
            measures.add(null);
        }

        // Package values into Measure instances in measures:
        int indexOfQuantity = -1;
        for (int i = 0, n = unitsConverters_.size(); i < n; ++i) {
            if (i < n - 1) {
                Measure measure =
                        new Measure(
                                intValues.get(i).multiply(sign), units_.get(i).unitImpl.build());
                measures.set(units_.get(i).index, measure);
            } else {
                indexOfQuantity = units_.get(i).index;
                Measure measure =
                        new Measure(
                                quantity.multiply(BigDecimal.valueOf(sign.longValue())),
                                units_.get(i).unitImpl.build());
                measures.set(indexOfQuantity, measure);
            }
        }

        return new ComplexConverterResult(indexOfQuantity, measures);
    }

    /**
     * Applies the rounder to the quantity (last element) and bubble up any carried value to all the
     * intValues.
     *
     * @return the rounded quantity
     */
    private BigDecimal applyRounder(
            List<BigInteger> intValues, BigDecimal quantity, Precision rounder) {
        if (rounder == null) {
            return quantity;
        }

        DecimalQuantity quantityBCD = new DecimalQuantity_DualStorageBCD(quantity);
        rounder.apply(quantityBCD);
        quantity = quantityBCD.toBigDecimal();

        if (intValues.size() == 0) {
            // There is only one element, Therefore, nothing to be done
            return quantity;
        }

        // Check if there's a carry, and bubble it back up the resulting intValues.
        int lastIndex = unitsConverters_.size() - 1;
        BigDecimal carry =
                unitsConverters_
                        .get(lastIndex)
                        .convertInverse(quantity)
                        .multiply(EPSILON_MULTIPLIER)
                        .setScale(0, RoundingMode.FLOOR);
        if (carry.compareTo(BigDecimal.ZERO) <= 0) { // carry is not greater than zero
            return quantity;
        }
        quantity = quantity.subtract(unitsConverters_.get(lastIndex).convert(carry));
        intValues.set(lastIndex - 1, intValues.get(lastIndex - 1).add(carry.toBigInteger()));

        // We don't use the first converter: that one is for the input unit
        for (int j = lastIndex - 1; j > 0; j--) {
            carry =
                    unitsConverters_
                            .get(j)
                            .convertInverse(BigDecimal.valueOf(intValues.get(j).longValue()))
                            .multiply(EPSILON_MULTIPLIER)
                            .setScale(0, RoundingMode.FLOOR);
            if (carry.compareTo(BigDecimal.ZERO) <= 0) { // carry is not greater than zero
                break;
            }
            intValues.set(
                    j,
                    intValues
                            .get(j)
                            .subtract(unitsConverters_.get(j).convert(carry).toBigInteger()));
            intValues.set(j - 1, intValues.get(j - 1).add(carry.toBigInteger()));
        }

        return quantity;
    }

    @Override
    public String toString() {
        return "ComplexUnitsConverter [unitsConverters_="
                + unitsConverters_
                + ", units_="
                + units_
                + "]";
    }
}
